翻译《Shorebird:How Code Push Works》
Flutter 祖师爷 Eric Seidel,从 Google 离职后,创建了一个名为 Shorebird 的创业项目,针对 Flutter 在移动端动态性不足的缺陷,研发了一套 Flutter 动态化解决方案。
随着 Shorebird 1.0 版本的发布,该项目逐渐进入开发者视线。在体验到惊艳的 Shorebird 的同时,大家有共同好奇:这到底是怎么实现的?
显然,底层原理是 Shorebird 商业机密,是其立足根本。但是,如果用户对 Shorebird 底层不理解,又如何信任它,如何敢将自己的业务交付于它?
在种种追问与担忧之下,Eric Seidel 亲自撰写了这篇《How Code Push Works》博客,原文地址:https://shorebird.dev/blogs/how-we-built-code-push/
我这篇博客进行了双语翻译,本文后续内容即我的翻译内容。
One of the most common questions we get, is “how does Shorebird work?“. This article describes some of the changes we made to Dart and Flutter in order to make code push work. If you have more questions, send us an email or ask on Discord and we’ll be happy to answer them or include them in a future article.
我们经常被问到的一个问题是:“Shorebird 是如何工作的?”。这篇文章描述了我们为实现代码推送而对 Dart 和 Flutter 所做的一些更改。如果你有更多问题,请给我们发送电子邮件或在 Discord 上询问,我们很乐意回答你的问题,或者在未来的文章中包含这些问题。
Code Push
Code push, sometimes called “over the air updates”, is a way of updating application code in production so that all your users are always running the latest code – just like how a web application works. Code push for Flutter is one of the top 50 most upvoted issues across all of GitHub. Code push is a helpful tool to allow developers to push small updates to their applications without having to force all your users to download a new version of your app.
代码推送,有时被称为“空中更新”,是一种在生产环境中更新应用程序代码的方法,以确保所有用户始终运行最新代码——就像网页应用程序一样。Flutter 的代码推送是 GitHub 上最受欢迎的前 50 个问题之一。代码推送是一个有用的工具,它允许开发者推送小的更新到他们的应用程序,而无需强迫所有用户下载新版本的应用程序。
This blog takes a closer look at how we built a custom Dart toolchain and runtime to make apps updatable in production. For more information on the architecture of Shorebird Code Push, check out our docs.
这篇博客深入探讨了我们如何构建自定义的 Dart 工具链和运行时,以使应用程序在生产环境中可更新。有关 Shorebird 代码推送架构的更多信息,请查看我们的文档。
How Does Code Push Work?
Existing code push solutions have typically relied on WebViews or Lua scripts, and require developers to use different languages and frameworks for different parts of their applications. These also implicitly require developers to be able to predict where their code will have bugs, since only some parts of their applications are updatable and others not. At Shorebird, when we sat down to build code push for Flutter, we wanted to build something better.
现有的代码推送解决方案通常依赖于 WebView 或 Lua 脚本,并要求开发者在应用程序的不同部分使用不同的语言和框架。这些解决方案还隐含地要求开发者能够预测代码可能出现的问题,因为只有应用程序的某些部分是可更新的,而其他部分则不是。在 Shorebird,我们在为 Flutter 构建代码推送时,想要创建一个更好的解决方案。
Shorebird’s code push for Flutter allows developers to update their Flutter apps instantly, over the air, deploying fixes directly to end users’ devices. Shorebird takes < 5 minutes to integrate and requires no code changes. Our code push can update any Dart code in your app. We’ve designed our system to comply with Apple and Google store policies without sacrificing performance (even after patching).
Shorebird 的 Flutter 代码推送允许开发者即时通过空中更新来更新他们的 Flutter 应用程序,将修复直接部署到终端用户的设备上。Shorebird 的集成时间少于 5 分钟,并且不需要更改代码。我们的代码推送可以更新应用程序中的任何 Dart 代码。我们设计的系统符合苹果和谷歌商店的政策,同时不牺牲性能(即使在打补丁后)。
Shorebird code push consists of:
- A command line program — the
shorebird
command knows how to wrapflutter
, including pulling down its own fork of Flutter’s engine. - A fork of Flutter’s engine — this includes our custom updater, a library we built to manage patches in your application.
- A cloud service at shorebird.dev — this stores information about your releases (builds you send to the stores) and patches (changes you make to releases via Shorebird), control rollout, see analytics, etc.
- A fork of the Dart compiler toolchain — this makes it possible to construct and run these “patches” to your application.
Shorebird 代码推送包括:
- 一个命令行程序——shorebird 命令知道如何包装 flutter,包括拉取其自己的 Flutter 引擎分支。
- 一个 Flutter 引擎的分支——这包括我们自定义的更新程序,一个用于管理应用程序补丁的库。
- 一个位于 shorebird.dev 的云服务——这个服务存储关于你的发布信息(你发送到商店的构建)和补丁(你通过 Shorebird 对发布的更改),控制推出进程,查看分析等。
- 一个 Dart 编译器工具链的分支——这使得构建和运行这些应用程序补丁成为可能。
A Closer Look at Dart
Dart has a “hot reload” feature used commonly during Flutter development. This uses Dart’s “just-in-time” (JIT) compiler. A JIT compiler is a way of turning source code into machine code right before the computer executes it. It’s the way that JavaScript, Lua and many other languages typically work. Shorebird does not use Dart’s JIT. Instead we use a custom interpreter we built. An interpreter is code that is used to execute logic from source code directly, without “compiling” it (translating it to machine code). This is important in the context of updates, because use of an interpreter is required by Apple’s developer agreement when updating applications. Dart did not have a production-ready interpreter, but was designed in such a way that adding one was possible, so we did.
Dart 具有一个“热重载”功能,通常在 Flutter 开发中使用。这依赖于 Dart 的“即时编译器”(JIT)。JIT 编译器是一种在计算机执行代码之前将源代码转换为机器代码的方法。这是 JavaScript、Lua 和许多其他语言通常使用的方式。Shorebird 并没有使用 Dart 的 JIT。相反,我们使用了我们自建的解释器。解释器是一种直接执行源代码逻辑的代码,不需要“编译”(将其转换为机器代码)。这在更新应用程序时非常重要,因为根据苹果的开发者协议,使用解释器是更新应用程序的必需条件。Dart 最初没有一个可用于生产环境的解释器,但其设计使得添加解释器成为可能,所以我们实现了这一点。
Just-in-time (JIT) systems have several nice properties. One is flexibility – a JIT’d language like JavaScript can run source code it’s never seen before in production. Another is that a JIT can be very good at “peak performance”, since a JIT runtime includes a compiler during production, which means a sophisticated JIT can run some code, measure that it’s being run very often and then go back and compile the same code in a more optimized way (with different tradeoffs) to make it run faster (a type of “profile guided optimization”). In an ahead-of-time (AOT) compiled language (like Swift), the source code is only used on the developer’s machine to produce the “machine code” which will end up running on the user’s device. AOT solutions have the nice advantage of not including a compiler in production (yields a smaller binary size) as well as having faster startup because there is no work to do when starting the app. At the tradeoff of some amount of peak performance as well as flexibility.
即时编译(JIT)系统有几个优点。首先是灵活性——像 JavaScript 这样的 JIT 语言可以在生产环境中运行从未见过的源代码。其次,JIT 可以在“峰值性能”方面表现得非常好,因为 JIT 运行时包含一个编译器,这意味着一个复杂的 JIT 可以运行一些代码,检测它被频繁运行,然后回过头来以更优化的方式(具有不同的权衡)重新编译相同的代码,以使其运行得更快(这是一种“基于性能的优化”)。在预编译(AOT)语言(如 Swift)中,源代码仅在开发者的机器上用于生成将在用户设备上运行的“机器代码”。AOT 方案有一个显著优势,就是生产环境中不包含编译器(从而产生更小的二进制文件)以及更快的启动速度,因为启动应用程序时不需要进行任何编译工作。其缺点则是峰值性能和灵活性有所减少。
Dart is an atypical language in that it has both JIT and AOT compiler workflows. Dart was designed originally as a JIT’d language, but is now most commonly used (as part of Flutter) with an AOT workflow. flutter run –debug
uses Dart’s JIT mode, but flutter build ipa
uses Dart’s AOT mode. Another side-effect of being a JIT is that typically much more information about the source code is kept around during production. This extra information is part of what enables a JIT to optimize hot functions, it’s also exactly the kind of information that has enabled what Shorebird has done.
Dart 是一种非典型语言,因为它同时拥有 JIT 和 AOT 编译工作流程。Dart 最初设计为 JIT 语言,但现在最常用的是与 Flutter 一起以 AOT 工作流程使用。flutter run --debug
使用 Dart 的 JIT 模式,但 flutter build ipa
使用 Dart 的 AOT 模式。作为 JIT 的另一个副作用是,生产环境中通常会保留更多的源代码信息。这些额外的信息部分使 JIT 能够优化热点函数,也是 Shorebird 所利用的信息。
Functions within a JIT runtime need to be aware they could have different compiled representations (e.g. one simple compile and one later optimized compile for the same function). Shorebird takes advantage of this quirk of Dart’s architecture to insert a new interpreter as an alternative mechanism for a function to use to execute. This allows us to effectively replace parts of your application at runtime without needing to compile new code on the device.
在 JIT 运行时环境中,函数需要意识到它们可能有不同的编译表示(例如,同一个函数可以有一个简单的编译版本和一个后期优化的编译版本)。Shorebird 利用 Dart 体系结构的这个特点,插入一个新的解释器作为函数执行的替代机制。这使我们能够在运行时有效地替换应用程序的部分,而不需要在设备上编译新的代码。
Building a Custom Interpreter for Dart
Adding an interpreter to Dart was a challenge (working on compilers is hard, if you like compilers, we’re hiring), and took us most of the last year. We didn’t try to build a particularly fancy interpreter for Dart (that had been attempted at Google multiple times before), but rather built something very simple. One of the challenges this creates is that interpreters (and particularly our simple one) are very slow. In our case, our current (unoptimized) interpreter is about 100x slower than executing Dart AOT code on a CPU. Thankfully, we had an insight early on that made us not have to care about interpreter speed.
为 Dart 添加一个解释器是一项挑战(编译器的工作非常困难,如果你喜欢编译器,我们正在招聘),我们花了过去的大部分时间才完成。我们并没有尝试为 Dart 构建一个特别复杂的解释器(谷歌之前已经多次尝试过),而是构建了一个非常简单的解释器。这带来的一个挑战是,解释器(尤其是我们这种简单的解释器)速度非常慢。我们的当前(未优化)解释器比在 CPU 上执行 Dart AOT 代码大约慢 100 倍。幸运的是,我们早期的一个洞见使得我们不必关心解释器的速度。
This insight is that we can run only changed Dart logic on the interpreter, while continuing to run unchanged code on the CPU. Since the vast majority of the performance-critical Dart code in a Flutter program is typically the Flutter framework itself, essentially all of your application would end up running (at full speed) on the CPU, and your program as a whole would show unchanged performance. This was our bet. Other than being extremely hard to make work, it paid off.
这个洞见是我们可以仅在解释器上运行更改过的 Dart 逻辑,而继续在 CPU 上运行未更改的代码。由于 Flutter 程序中绝大多数性能关键的 Dart 代码通常是 Flutter 框架本身,所以实际上你的应用程序大部分都会在 CPU 上运行(全速),而整个程序的性能将保持不变。这是我们的一次赌注。尽管非常难以实现,但最终得到了回报。
Determining what parts of your program we could run on the CPU vs. interpreter was hard. To do this we invented a new phase of Dart compilation we called the “linker”. The linker’s job is to analyze two (similar) Dart programs and find the maximal similarity between them and then decide what would be necessary to update in the first one in order to make it run like the second. We still have a couple missing optimizations in this part of our system, but when it works well, developers see 99% of their code run on the CPU (even for large changes). Teaching the linker how to figure this out however required significant changes to Dart’s compiler toolchain.
确定程序的哪些部分可以在 CPU 上运行,哪些部分需要在解释器上运行是很困难的。为此,我们发明了 Dart 编译的一个新阶段,我们称之为“链接器”。链接器的工作是分析两个(相似的)Dart 程序,找到它们之间的最大相似性,然后决定需要在第一个程序中更新哪些部分才能使其像第二个程序一样运行。在这个系统的这一部分,我们还有一些优化尚未完成,但当它运行良好时,开发人员会看到 99% 的代码在 CPU 上运行(即使是对于大改动)。教会链接器如何做到这一点需要对 Dart 的编译器工具链进行重大修改。
Changes to Dart’s Toolchain
We made many changes to Dart, including:
- Runtime changes to add our interpreter.
- Compiler changes teach the Dart compiler how to compile a program maximally similar to a previous version of the same program.
- A new linker which can compare parts two versions of an app and decide if and how they’ve changed.
And much more — too many to go through here, but I’ll give one example of a problem we recently solved.
我们对 Dart 进行了许多更改,包括:
- 为添加我们的解释器所做的运行时更改。
- 编译器更改,以教会 Dart 编译器如何编译出与之前版本最大程度相似的程序。
- 一个新的链接器,可以比较应用程序的两个版本的部分内容,并决定它们是否以及如何发生了变化。
还有很多其他更改——太多了,无法在这里一一详述,但我会举一个我们最近解决的问题的例子。
Optimizing Dart’s constant pool
In programming languages it is typical to have “constants” which are variables that don’t change at runtime. These are often pre-computed during compile time, saved in a common space and shared throughout the program. E.g. if you have the string “hello” in your program many times, most compilers will only allocate a single string “hello” and share it throughout your program.
在编程语言中,通常会有“常量”,即在运行时不改变的变量。这些常量通常在编译时预先计算,并保存在一个公共空间中,在整个程序中共享。例如,如果在程序中多次出现字符串“hello”,大多数编译器只会分配一个字符串“hello”并在整个程序中共享。
Dart implements this using something called the “Object Pool” (aka a constant pool). In Dart’s JIT mode, each function ends up with its own Object Pool to hold constant references used within that function (e.g. strings, integers). Dart’s AOT combines all of these “Object Pools” into one global object pool and updates all parts of your program accordingly to reference slots in this global object pool. Objects in this pool are referenced by index, so the string “hello” mentioned above might be at index 1234 in the object pool and thus code compiled for your program would reference “hello” by the number 1234.
Dart 使用一种称为“对象池”(也称为常量池)的机制来实现这一点。在 Dart 的 JIT 模式下,每个函数都有自己的对象池,用于保存该函数中使用的常量引用(例如字符串、整数)。Dart 的 AOT 模式将所有这些“对象池”合并为一个全局对象池,并相应地更新程序的所有部分,以引用该全局对象池中的槽。这个池中的对象通过索引引用,所以上面提到的字符串“hello”可能在对象池中的索引为 1234,因此为你的程序编译的代码会通过数字 1234 来引用“hello”。
So why does this matter? This matters because when we’re trying to update your program if the new version of your code uses new constants (a very common occurrence), those new constants also need a slot in this pool (and index assigned). Worse is that if we add this slot in the middle of the pool, all of the references into the latter half of the pool would break (indices would change). If we’re trying to end up running code on the CPU, we have to be very careful never to change things that the pre-compiled code makes reference to.
那么这为什么重要呢?这很重要,因为当我们尝试更新你的程序时,如果代码的新版本使用了新的常量(这是很常见的情况),这些新常量也需要在这个池中占据一个槽位(并分配索引)。更糟糕的是,如果我们在池的中间添加这个槽位,池的后半部分的所有引用都会失效(索引会改变)。如果我们试图最终在 CPU 上运行代码,我们必须非常小心,绝不能改变预编译代码所引用的内容。
For example:
例如:
void main() {
print("hello");
print("world");
}
The dart compiler would produce an object pool:
Dart 编译器会产生如下对象池:
0 : "hello"
1 : "world"
If we then change that to be:
如果我们将其修改为:
void main() {
print("hello");
print("new");
print("world");
}
Dart object pool would be:
Dart 对象池将会是:
0: "hello"
1: "new"
2: "world"
Notice that that index for “world” has changed! That means that all parts of your program that reference “world” are now changed and thus we can’t use the previously compiled (fast) version of any functions which reference “world”, thus having to run all logic which references “world” on the interpreter.
注意到“world”的索引已经改变了!这意味着程序中引用“world”的所有部分现在都已更改,因此我们不能使用之前编译的(快速的)任何引用“world”的函数版本,因此所有引用“world”的逻辑都必须在解释器上运行。
We solved this by teaching the Dart compiler how to construct order-dependent structures (there are many of these) like the Object Pool in a stable ordered fashion. Importantly we taught Dart that sometimes when it’s processing a “patch”, it should assign these indices in the Object Pool (and similar data structures) to be maximally similar to how they were assigned in the provided “base” release.
我们通过教会 Dart 编译器如何以稳定的顺序构建依赖顺序的结构(如对象池)解决了这个问题。重要的是,我们教会了 Dart,有时在处理“补丁”时,它应该在对象池(以及类似的数据结构)中分配这些索引,使其尽可能类似于提供的“基础”版本中的分配方式。
As noted above, we had to make many more changes to Dart in order to make it work well for code push, but those we’ll have to save for another article.
如上所述,为了使 Dart 在代码推送时运行良好,我们不得不对其进行更多更改,但这些更改将留待另一篇文章中详述。
Conclusion
We made a lot of (difficult) changes to Dart so you don’t have to! Shorebird is a drop-in replacement for flutter build
and allows you to add code push to your app in only a few minutes – with no code changes required.
我们对 Dart 进行了许多(困难的)更改,这样你就不必费心了!Shorebird 是 flutter build 的一个即插即用替代方案,可以让你在几分钟内将代码推送功能添加到你的应用程序中——无需更改代码。
Shorebird is free to use for small applications, with pricing that scales with your business needs.
Shorebird 对于小型应用程序是免费的,价格会根据你的业务需求进行调整。
Learn more at our website: https://shorebird.dev. See most of our source code on GitHub: https://github.com/shorebirdtech/shorebird. If you ever have questions, our entire team is on Discord: https://discord.gg/shorebird.
了解更多信息,请访问我们的网站:https://shorebird.dev。我们的绝大多数源代码都在 GitHub 上:https://github.com/shorebirdtech/shorebird。如果你有任何问题,我们的整个团队都在 Discord 上:https://discord.gg/shorebird。
本文作者:Maeiee
本文链接:翻译《Shorebird:How Code Push Works》
版权声明:如无特别声明,本文即为原创文章,版权归 Maeiee 所有,未经允许不得转载!
喜欢我文章的朋友请随缘打赏,鼓励我创作更多更好的作品!